Skip to content

feat(stats): state both goodput ratios, per run and per operation - #79

Open
bdchatham wants to merge 2 commits into
brandon2/plt-1078-outcome-reportfrom
brandon2/plt-1079-goodput-ratios
Open

feat(stats): state both goodput ratios, per run and per operation#79
bdchatham wants to merge 2 commits into
brandon2/plt-1078-outcome-reportfrom
brandon2/plt-1079-goodput-ratios

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

TOT-005 through TOT-008. Sixth in the outcome-tracking stack. Closes User Story 2.

Both ratios, never one

Reporting one hides whichever layer it divides away.

Offered 200, accepted 190, rejected 10. Every count below is a share of the
190 accepted: a send that returned an error never reached the chain.
...
Goodput: 44.00% of the 200 offered, 46.32% of the 190 accepted.

Over offered answers what the profile asked for. A run whose endpoint refuses half shows a halved number, rather than a healthy one measured over the survivors.

Over accepted isolates chain execution, which is the number to read when the RPC layer is not what is under test.

The gap between them is exactly the endpoint's rejection share, so the report names that too.

An unobserved transaction lowers the ratio

It counts in the denominator and never in the numerator. Both alternatives read worse: counting it in the numerator reports a success nobody saw, and dropping it from the denominator reports a healthy ratio over the survivors — the same defect this feature removes one layer up. The separate status_unavailable count is what lets a reader tell a low ratio caused by the chain from one caused by the run.

Per operation, because the total cannot tell you where

Per Operation:
  erc20/approve: 100 TXs
    outcomes: committed=0 reverted=100 expired=0 unobserved=0 | goodput 0.00% offered, 0.00% accepted
  erc20/transfer: 100 TXs
    outcomes: committed=88 reverted=0 expired=0 unobserved=2 | goodput 88.00% offered, 97.78% accepted

That run reports 46% goodput at the top. A revert rate spread evenly across operations points at the chain; one concentrated in a single call points at the workload — a missing allowance, a wrong ABI, a bad address. The run-level total cannot separate them, and this tool is run ad-hoc against a devnet where the report is the only surface.

It prints as a second line rather than widening the existing one, which is already at its readable limit, so anything parsing that format keeps working.

Already done in the stack

T024 and T027 were satisfied by #69: TestOutcomesCarryTheOperation pins that outcomes arrive keyed by scenario and operation, not by scenario alone.

Checked

Every guard broken on purpose:

mutation caught
both ratios collapse to the accepted one yes
unobserved excused from the denominator yes
Rejected() underflows on an inverted pair yes

gofmt, go vet, full suite clean.

TOT-005 through TOT-008. Reporting one ratio hides whichever layer it divides
away, so the run states both: committed over every send it attempted, and
committed over the sends an endpoint accepted.

The first answers what the profile asked for. A run whose endpoint refuses
half shows a halved number rather than a healthy one measured over the
survivors. The second isolates chain execution, which is the number to read
when the RPC layer is not under test. The gap between them is the rejection
share, so the report names that too.

An indeterminate status counts in the denominator and never in the numerator.
Counting it in the numerator reports a success nobody saw; dropping it from
the denominator reports a healthy ratio over the survivors, which is the
defect this feature removes one layer up.

Every operation carries its own ledger. The run-level total cannot separate a
revert rate spread evenly across operations from one concentrated in a single
call, and those point at different things: the chain, or the workload. A run
reporting 46% goodput where one operation sits at 0% and another at 98% now
says so.

It prints as a second line under each operation rather than widening the
first, so anything parsing the existing format keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@cursor

cursor Bot commented Aug 29, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Additive stats and report formatting only; no runtime send path or security-sensitive logic, though JSON/report consumers see new fields and wording.

Overview
Execution outcomes now distinguish offered (Attempted) from accepted sends and always report two goodput ratios: committed over offered (reflects RPC refusals) and committed over accepted (chain execution only). The text report leads with offered/accepted/rejected counts and prints both percentages; Rejected(), GoodputOfAttempted(), and GoodputOfAccepted() encode the math, with unobserved txs still in the accepted denominator.

Per-operation reporting adds an Outcomes ledger on OperationReport (JSON + a second line under each operation in the final report with committed/reverted/expired/unobserved and both goodput figures). BuildFinalStats rolls up Attempted at run level and copies each operation’s outcome counters from the collector.

Tests lock in dual-ratio output, rejection naming, unobserved lowering goodput, separate revert vs expire sections, and safe Rejected() when attempted < accepted.

Reviewed by Cursor Bugbot for commit fede946. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctly wires an Attempted denominator and two goodput ratios through the run-level and per-operation reports; the field mapping and the inclusion.Get() hoist that keeps per-op and run-level Tracked in agreement both check out. Two non-blocking gaps: the per-operation line omits the drop and unrecorded counts, so a run whose own registry dropped an operation's transactions prints all zeros and reads as a chain failure, and none of the new tests cover the BuildFinalStats wiring.

Findings: 0 blocking | 3 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The untracked arm of ExecutionOutcomes.String (stats/execution_outcomes.go:154-161) still reports only "Endpoints accepted %d sends". Attempted and Rejected() are send-path facts that hold whether or not --track-receipts is on, and by this PR's own argument leaving the reader to subtract "Total TXs" from "accepted" is the arithmetic the feature exists to remove. Stating offered/accepted/rejected there too would cost one line.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread stats/logger.go
Comment thread stats/logger.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Attempted denominator, the two goodput ratios, and the per-operation ledger are wired correctly, and the single hoisted inclusion.Get() read keeps run-level and per-op Tracked from disagreeing. No changes have landed since the previous review, so both earlier non-blocking gaps are still open: the per-operation line omits the counts that would explain a zero, and nothing tests the BuildFinalStats wiring.

Findings: 0 blocking | 3 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The untracked arm of ExecutionOutcomes.String (stats/execution_outcomes.go:154-161) still reports only "Endpoints accepted %d sends". Attempted and Rejected() are send-path facts that hold whether or not --track-receipts is on, and by this PR's own argument, leaving the reader to subtract "Total TXs" from "accepted" is exactly the arithmetic the feature exists to remove. Stating offered/accepted/rejected there too would cost one line.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread stats/logger.go
Comment thread stats/logger.go
…9-goodput-ratios

# Conflicts:
#	stats/execution_outcomes.go
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Attempted denominator, the two goodput ratios and the per-operation ledger are wired correctly, and the hoisted inclusion.Get() keeps run-level and per-op Tracked in agreement. Non-blocking: the new doc comments assert an incorrect relationship between the two ratios (the gap is not the rejection share), the per-operation line omits the counts that would explain a zero, and nothing tests the BuildFinalStats wiring.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] The untracked arm of ExecutionOutcomes.String (stats/execution_outcomes.go:154-161) still reports only "Endpoints accepted %d sends". Attempted and Rejected() are send-path facts that hold whether or not --track-receipts is on, and by this PR's own argument, leaving the reader to subtract "Total TXs" from "accepted" is exactly the arithmetic the feature exists to remove. Stating offered/accepted/rejected there too would cost one line.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

}

// Rejected is a send an endpoint refused. It is a fact about the RPC layer, and
// the gap between the two goodput ratios below is exactly its share.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This claim is false in general, and the same claim is repeated at stats/execution_outcomes.go:197-198 and in the PR description.

The gap between the two ratios is

Committed/Accepted - Committed/Attempted

not Rejected/Attempted. The two coincide only when Committed == Accepted, which is exactly the case TestBothRatiosAreStated picks (Attempted: 200, Accepted: 100, Committed: 100 → gap 50%, rejection share 50%), so the test confirms the wrong invariant rather than catching it.

The PR body's own example disproves it: offered 200, accepted 190, goodput 44.00% / 46.32% → gap 2.32%, rejection share 10/200 = 5%.

What actually holds is multiplicative: GoodputOfAttempted = GoodputOfAccepted × (Accepted/Attempted), i.e. the ratio of the two ratios is the acceptance rate. Since these comments are how a reader is told to interpret the report, either state the multiplicative relation or drop the "exactly its share" wording and let rejected %d speak for itself.

Comment thread stats/logger.go
// its readable limit, and anything parsing it keeps working.
if op.Outcomes.Tracked {
result += fmt.Sprintf(
" outcomes: committed=%d reverted=%d expired=%d unobserved=%d | goodput %.2f%% offered, %.2f%% accepted\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Still open from the previous review — no changes have landed since.

The per-operation line prints four of the seven outcome counts, and the three it drops are the ones that would explain a zero. DroppedAtCap, DroppedAtHandoff and Unrecorded are omitted, so an operation whose transactions this run's own registry refused at cap (stats/inclusion_tracker.go:270) prints:

  erc20/transfer: 100 TXs | ...
    outcomes: committed=0 reverted=0 expired=0 unobserved=0 | goodput 0.00% offered, 0.00% accepted

Every visible count is zero and goodput is zero, which reads as a chain that committed nothing — the exact false accusation the run-level report is careful to avoid, and which ExecutionOutcomes.String avoids by making no row conditional ("An absent number reads as zero", stats/execution_outcomes.go:143-149). Unrecorded matters for the same reason: it has no legitimate producer, so a per-operation counting bug is invisible in the text report even though the run-level line surfaces it.

Adding dropped=%d (cap + handoff) and unrecorded=%d keeps the line reconcilable against Accepted without widening it much.

Comment thread stats/logger.go
LatencyP99: op.P99Latency,
SampleCount: op.SampleCount,
Window: op.Window,
Outcomes: ExecutionOutcomes{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Still open from the previous review — no test reaches this wiring.

execution_outcomes_test.go exercises Rejected/GoodputOfAttempted/GoodputOfAccepted against struct literals it builds itself, and the only BuildFinalStats caller in the package (stats/logger_order_test.go:72) passes utils.None[stats.InclusionSummary](), so Tracked is false and the per-operation outcomes line never renders. That leaves the per-operation Outcomes mapping here, the new execution.Attempted += op.Count sum, and the report line in FinalStats.String all untested.

That is the half of the PR the user story is about ("per operation, because the total cannot tell you where"), and this mapping is exactly the kind that fails silently: a transposed Expired/DroppedAtCap, or an Attempted left unset, still compiles and still prints a plausible-looking percentage. A test in the style of TestOperationReportOrderIsStable that records a couple of operations plus outcomes on the collector, builds with a Some(...) inclusion summary, and asserts the per-op ledger and Execution.Attempted would pin it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant